Skip to content

refactor(storage): one spill pipeline — sync eviction uses the durable batch writer (W2)#397

Merged
pilotspacex-byte merged 1 commit into
mainfrom
refactor/w2-spill-pipeline
Jul 20, 2026
Merged

refactor(storage): one spill pipeline — sync eviction uses the durable batch writer (W2)#397
pilotspacex-byte merged 1 commit into
mainfrom
refactor/w2-spill-pipeline

Conversation

@pilotspacex-byte

Copy link
Copy Markdown
Contributor

Summary

W2 of the storage unification wave. Stacked on #396 (W1) — merge that first; this PR's base is refactor/w1-value-codec.

Moon had two spill families: the async/batch path (flush_buffer: shared multi-entry files, one manifest commit per file) and a sync per-victim path (spill_to_datafile: one single-entry .mpf file + one shard-thread-blocking durable manifest commit per key) used by the eviction tick and the memory-pressure cascade. The seam between them caused three past bugs (task #34 defect 1, task #45 is_string gate, #139 test blindness). This PR makes the batch machinery the only spill implementation.

  • select_victim (policy dispatch) and build_spill_payload (victim serialization) exist once — were copy-pasted ×3.
  • evict_batch_durable_no_aofevict_batch_durable, now backing every SpillContext sync path; evict_one_with_spill's spill arm is a deficit-1 delegation, inheriting (not re-implementing) write-then-durable-then-drop, D1 ordering, and fail-closed I/O-error retention.
  • Red-first: sync-evicting 40 keys now produces 1 shared file (was 40 files + 40 blocking fsync round-trips).
  • Fail-closed (red-first): unserializable victims are retained hot and skipped — previously unwrap_or_default() spilled an EMPTY value body and evicted the key (silent durable data loss) at all three sites.
  • Batch path now records the eviction metric (previously under-counted).
  • spill_to_datafile demoted to the single-entry test-fixture primitive.

Gates (Linux VM, release-fast, MOON_BIN pinned to fresh ELF)

  • crash_matrix_cross_plane -- --ignored: 46/46
  • crash_recovery_spill_batch_kill9 2/2, crash_recovery_disk_offload_no_aof 6/6, cold_collection_visibility 4/4, cold_orphan_sweep 5/5, cold_shadow_overwrite_resurrection, scan/dbsize/used_memory offload suites — all green
  • lib suite 4421 (macOS) / VM green; clippy -D warnings × both feature sets; all historical eviction pin tests green

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@TinDang97, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 57 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0569eb3b-e332-41ca-94d9-40eb20343449

📥 Commits

Reviewing files that changed from the base of the PR and between 21fccea and 568e2d9.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • src/config.rs
  • src/storage/db.rs
  • src/storage/eviction.rs
  • src/storage/tiered/kv_spill.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/w2-spill-pipeline

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…the durable batch writer (W2)

Moon had two spill families: the async/batch path (SpillThread
flush_buffer: shared multi-entry files, one manifest commit per file)
and a sync per-victim path (kv_spill::spill_to_datafile: ONE
single-entry file + ONE shard-thread-blocking durable manifest commit
PER KEY) used by the background eviction tick, the memory-pressure
cascade's sync fallback, and db_quota. The two evolved independently
and that seam already bit three times: the task #34 plain-drop
misclassification, the task #45 is_string gate, and the #139 spill-test
blindness were all divergence bugs between these copies.

This PR makes the batch machinery the ONE spill implementation:

- select_victim (policy dispatch) and build_spill_payload (victim
  serialization) exist once — previously copy-pasted at all three
  entry points.
- evict_batch_durable_no_aof is renamed evict_batch_durable and now
  backs every SpillContext sync path: the sync wrapper reclaims its
  whole deficit as batches; evict_one_with_spill's spill arm is a
  deficit-1 delegation. Write-then-durable-then-drop ordering, D1
  remove-before-cold-index, and fail-closed retention on I/O error are
  inherited, not re-implemented.
- Red-first: sync-evicting 40 keys now produces 1 shared .mpf file
  (was 40 files + 40 blocking manifest fsync round-trips).
- Fail-closed serialization (red-first): all three sites previously
  did serialize_collection(..).unwrap_or_default() — a corrupt value
  was "spilled" as an EMPTY body and evicted (silent durable data
  loss on reload). Unserializable victims are now retained hot,
  skipped, and loudly logged, on the sync, batch, and async paths.
- evict_batch_durable now calls record_eviction() per removed key
  (the batch path previously under-counted the eviction metric).
- kv_spill::spill_to_datafile is no longer a production eviction path;
  it remains the single-entry primitive test fixtures use (same page
  layout the batch salvage path emits).

Gates: lib suite 4421 green (macOS + Linux VM release-fast); eviction
pin tests (spill-I/O-failure OOM retention, non-string durable spill,
db_index restamp) all green; offload/cold integration suites + crash
matrix on Linux VM; clippy -D warnings on default and tokio,jemalloc
feature sets.

Stacked on refactor/w1-value-codec (PR #396).

author: Tin Dang
@TinDang97
TinDang97 force-pushed the refactor/w2-spill-pipeline branch from 6716c4d to 568e2d9 Compare July 20, 2026 05:35
@pilotspacex-byte
pilotspacex-byte merged commit b5204f4 into main Jul 20, 2026
7 of 8 checks passed
TinDang97 added a commit that referenced this pull request Jul 20, 2026
…ossil base_ts plumbing removed (W3)

CompactEntry stored key expiry as SECONDS in its u64 ttl field
(`ms / 1000` floor on every set_expires_at_ms). Consequences:

- PEXPIRE key 1500 expired at the 1000 ms boundary — every
  millisecond-granularity TTL fired up to 999 ms EARLY.
- PTTL reported the second-truncated deadline, not the one the client
  set.
- The RDB round-trip test needed a 5-second tolerance to pass.

The u64 was already paid for; this stores absolute Unix milliseconds
in it directly (0 = no expiry, unchanged sentinel; 32-byte entry
layout and const assert unchanged). is_expired_at compares in ms,
expires_at_ms/PTTL read back the exact deadline, and the RDB
round-trip test now asserts exact millisecond equality (red-first:
ttl_millisecond_fidelity pinned exact readback + boundary behavior
at deadline-1 ms and deadline).

Second commit-sized cleanup folded in: the expiry API carried a
fossil base_ts: u32 parameter (ignored since expiry became absolute)
on is_expired_at / expires_at_ms / set_expires_at_ms /
new_string_with_expiry. The parameter is gone, along with ~60 dead
call-site arguments across command/persistence/storage/shard, the
dead `let base_ts = …` bindings that fed them, Database::
check_expired's and is_expired's fossil params, rdb::write_entry's
and redis_rdb::write_rdb_entry's fossil params, and the never-read
SnapshotState.base_timestamps field (plus its constructor params and
the per-tick Vec that persistence_tick built to fill it). The AOF
rewrite snapshot type shrank from Vec<(Vec<(Key, Entry)>, u32)> to
Vec<Vec<(Key, Entry)>> end-to-end (AofFoldSnapshot.dbs, the shard-
thread builder, save_from_snapshot / save_snapshot_to_bytes, both
BGSAVE builders, and the rewrite temp-db loop) — every consumer
ignored the u32. merge_shard_snapshots (zero callers) is deleted.

On-disk formats are untouched: RDB/spill/AOF always carried absolute
milliseconds — the truncation happened only at the in-memory Entry
boundary on load/set.

Gates: lib suite 4422 green (macOS + Linux VM release-fast, monoio +
tokio,jemalloc feature sets); clippy -D warnings on both feature
sets; fmt; crash-matrix + offload suites on Linux VM.

Stacked on refactor/w2-spill-pipeline (PR #397).

author: Tin Dang
pilotspacex-byte added a commit that referenced this pull request Jul 20, 2026
…ossil base_ts plumbing removed (W3) (#398)

CompactEntry stored key expiry as SECONDS in its u64 ttl field
(`ms / 1000` floor on every set_expires_at_ms). Consequences:

- PEXPIRE key 1500 expired at the 1000 ms boundary — every
  millisecond-granularity TTL fired up to 999 ms EARLY.
- PTTL reported the second-truncated deadline, not the one the client
  set.
- The RDB round-trip test needed a 5-second tolerance to pass.

The u64 was already paid for; this stores absolute Unix milliseconds
in it directly (0 = no expiry, unchanged sentinel; 32-byte entry
layout and const assert unchanged). is_expired_at compares in ms,
expires_at_ms/PTTL read back the exact deadline, and the RDB
round-trip test now asserts exact millisecond equality (red-first:
ttl_millisecond_fidelity pinned exact readback + boundary behavior
at deadline-1 ms and deadline).

Second commit-sized cleanup folded in: the expiry API carried a
fossil base_ts: u32 parameter (ignored since expiry became absolute)
on is_expired_at / expires_at_ms / set_expires_at_ms /
new_string_with_expiry. The parameter is gone, along with ~60 dead
call-site arguments across command/persistence/storage/shard, the
dead `let base_ts = …` bindings that fed them, Database::
check_expired's and is_expired's fossil params, rdb::write_entry's
and redis_rdb::write_rdb_entry's fossil params, and the never-read
SnapshotState.base_timestamps field (plus its constructor params and
the per-tick Vec that persistence_tick built to fill it). The AOF
rewrite snapshot type shrank from Vec<(Vec<(Key, Entry)>, u32)> to
Vec<Vec<(Key, Entry)>> end-to-end (AofFoldSnapshot.dbs, the shard-
thread builder, save_from_snapshot / save_snapshot_to_bytes, both
BGSAVE builders, and the rewrite temp-db loop) — every consumer
ignored the u32. merge_shard_snapshots (zero callers) is deleted.

On-disk formats are untouched: RDB/spill/AOF always carried absolute
milliseconds — the truncation happened only at the in-memory Entry
boundary on load/set.

Gates: lib suite 4422 green (macOS + Linux VM release-fast, monoio +
tokio,jemalloc feature sets); clippy -D warnings on both feature
sets; fmt; crash-matrix + offload suites on Linux VM.

Stacked on refactor/w2-spill-pipeline (PR #397).

author: Tin Dang

Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
@TinDang97
TinDang97 deleted the refactor/w2-spill-pipeline branch July 20, 2026 05:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants